密度プロット#

密度プロットDensity Plot ) とは、主に量的変数に対して、分布の形状をカーネル密度推定[1]による 曲線 で表現する可視化手法です。

ヒストグラムより滑らかに分布を表現することが可能ですが、あくまでも推定結果であることに注意が必要です。 また、密度プロットにおいては、 X軸に関する積分結果が1になるようにY軸のスケールが調整される ため、Y軸の数値が直感にそぐわないものになることがあります。 あくまでも相対的な度数として捉える必要があるでしょう。

ヒストグラムと異なり、複数の凡例を重ね合わせて表示することができます。 しかし、凡例ごとにY軸方向のスケール調整が行われるため、 凡例同士の絶対量を比較することはできない 、という点は覚えておきましょう。 密度プロットの重畳表示は受け手にも誤った印象を与える可能性があるため、個人的には、サブプロットに分けて表現することが多いです。

アニメ作品の合計話数の分布を表現した密度プロットを用いて、具体的に説明します。 密度プロットでは、一つ目の 位置 スケール(上図「位置①」)で量的変数の値(上図「25.354」)を指定し、それと直交する二つ目の 位置 スケール(上図「位置②」)による曲線で確率密度(「0.02545366」)を表します。

Plotlyでは、plotly.figure_factory.create_distplot()で作成可能です。

# plotlyのfigure_factoryをffという名前でインポート
# 複雑な図を高度にカスタマイズして作成するためのモジュール
import plotly.figure_factory as ff

# ff.create_distplot関数を使用して、密度プロットを作成
# 'hist_data'には各データセットのリスト、'group_labels'には各データセットのラベル名のリストを渡す
# show_hist=Falseとすることで、ヒストグラムを非表示にする
# show_rug=Falseとすることで、Rug(実データの詳細な位置を短い線で示したサブプロット)を非表示にする
# 作成した図は'fig'という変数に保存される
fig = ff.create_distplot(hist_data, group_labels, show_hist=False, show_rug=False)

他の可視化手法と一貫性を保つため、本Notebookではplotly.expressライクな関数であるcreate_distplotを定義します。

# create_distplot関数を使用して、データフレームから密度プロットとヒストグラムを作成
# 'df'にはプロットするデータを含むデータフレームを、'x'には密度プロットの描画対象とするカラム名を渡す
# 'color'はデータを分割する基準とするカラム名で、これに基づいてデータはグループ分けされる
# **kwargsを利用して、ff.create_distplotに任意のオプション引数を渡すことができる
# 作成したプロットは'fig'という変数に保存され、この'fig'を返り値として返す
fig = create_distplot(df, x="value", color="category")

関数の定義は、こちらをご参照ください。

初期設定#

以降では、マンガ・アニメ・ゲームデータを可視化するための初期設定を行います。 なお、紙幅の都合のため、書籍版と一部構成が異なることにご注意ください。

Import#

必要なライブラリをImportします。

Hide code cell content
# warningsモジュールのインポート
import warnings

# データ解析や機械学習のライブラリ使用時の警告を非表示にする目的で警告を無視
# 本書の文脈では、可視化の学習に議論を集中させるために選択した
# ただし、学習以外の場面で、警告を無視する設定は推奨しない
warnings.filterwarnings("ignore")
Hide code cell content
# pathlibモジュールのインポート
# ファイルシステムのパスを扱う
from pathlib import Path

# typingモジュールからの型ヒント関連のインポート
# 関数やクラスの引数・返り値の型を注釈するためのツール
from typing import Any, Dict, List, Optional, Union

# numpy:数値計算ライブラリのインポート
# npという名前で参照可能
import numpy as np

# pandas:データ解析ライブラリのインポート
# pdという名前で参照可能
import pandas as pd

# plotly.expressのインポート
# インタラクティブなグラフ作成のライブラリ
# pxという名前で参照可能
import plotly.express as px

# plotly.figure_factoryのインポート
# 高度なプロットとデータ可視化のためのユーティリティ
# ffという名前で参照可能
import plotly.figure_factory as ff

# plotly.graph_objectsからFigureクラスのインポート
# 型ヒントの利用を主目的とする
from plotly.graph_objects import Figure

# plotly.subplotsからmake_subplotsのインポート
# 複数のサブプロットを含む複合的な図を作成する際に使用
from plotly.subplots import make_subplots

なお、型ヒントについてはこちらを参照ください。

定数#

本Notebookで用いる定数を定義します。 なお、Pythonにおける定数の扱いについては、こちらを参照ください。

Hide code cell content
# マンガデータ保存ディレクトリのパス
DIR_CM = Path("../../data/cm/input")
# アニメデータ保存ディレクトリのパス
DIR_AN = Path("../../data/an/input")
# ゲームデータ保存ディレクトリのパス
DIR_GM = Path("../../data/gm/input")

# マンガデータの分析結果の出力先ディレクトリのパス
DIR_OUT_CM = DIR_CM.parent / "output" / Path.cwd().parts[-1] / "density"
# アニメデータの分析結果の出力先ディレクトリのパス
DIR_OUT_AN = DIR_AN.parent / "output" / Path.cwd().parts[-1] / "density"
# ゲームデータの分析結果の出力先ディレクトリのパス
DIR_OUT_GM = DIR_GM.parent / "output" / Path.cwd().parts[-1] / "density"
Hide code cell content
# 読み込み対象ファイル名の定義

# Comic Episode関連のファイル名
FN_CE = "cm_ce.csv"

# Anime Episode関連のファイル名
FN_AE = "an_ae.csv"

# PacKaGeとPlatForm関連のファイル名
FN_PKG_PF = "gm_pkg_pf.csv"
Hide code cell content
# 可視化に関する設定値の定義

# 可視化対象とするマンガ作品の条件として、最小の各話数を定義
MIN_N_CE = 5

# 可視化対象とするアニメ作品の条件として、最小の各話数を定義
MAX_N_AE = 100

# 可視化対象とするゲームパッケージの条件として、価格の最大値を定義
MAX_PRICE = 10000
Hide code cell content
# pandasのweekday関数で取得できる曜日の数値と実際の曜日名を対応させる辞書を定義
# 0:月曜日, 1:火曜日, ... , 6:日曜日
WEEKDAY2YOBI = {
    0: "月",
    1: "火",
    2: "水",
    3: "木",
    4: "金",
    5: "土",
    6: "日",
}
Hide code cell content
# Okabe and Ito 2008
# https://jfly.uni-koeln.de/color/#pallet
OKABE_ITO = [
    "#000000",  # 黒 (Black)
    "#E69F00",  # 橙 (Orange)
    "#56B4E9",  # 薄青 (Sky Blue)
    "#009E73",  # 青緑 (Bluish Green)
    "#F0E442",  # 黄色 (Yellow)
    "#0072B2",  # 青 (Blue)
    "#D55E00",  # 赤紫 (Vermilion)
    "#CC79A7",  # 紫 (Reddish Purple)
]
Hide code cell content
# plotlyの描画設定の定義

# plotlyのグラフ描画用レンダラーの定義
# Jupyter Notebook環境のグラフ表示に適切なものを選択
RENDERER = "plotly_mimetype+notebook"

関数#

以下では、本Notebookで用いる関数を定義します。

Hide code cell content
def show_fig(fig: Figure) -> None:
    """
    所定のレンダラーを用いてplotlyの図を表示
    Jupyter Bookなどの環境での正確な表示を目的とする

    Parameters
    ----------
    fig : Figure
        表示対象のplotly図

    Returns
    -------
    None
    """

    # 図の周囲の余白を設定
    # t: 上余白
    # l: 左余白
    # r: 右余白
    # b: 下余白
    fig.update_layout(margin=dict(t=25, l=25, r=25, b=25))

    # 所定のレンダラーで図を表示
    fig.show(renderer=RENDERER)
Hide code cell content
def create_distplot(
    df: pd.DataFrame,
    x: str,
    color: str = None,
    show_hist: bool = False,
    show_rug: bool = False,
    **kwargs: Any
) -> Figure:
    """
    データフレームから密度プロットとヒストグラムを作成する

    Parameters
    ----------
    df : pd.DataFrame
        プロットするデータを含むデータフレーム
    x : str
        密度プロットの描画対象とするカラム名
    color : str, optional
        データを分割する基準とするカラム名、指定しない場合はx列の全データを用いる
    show_hist : bool, optional
        ヒストグラムを表示するか否か、デフォルトはFalse
    show_rug : bool, optional
        ラグプロットを表示するか否か、デフォルトはFalse
    **kwargs
        ff.create_distplotに渡すその他のキーワード引数

    Returns
    -------
    Figure
        作成されたプロットのFigureオブジェクト
    """

    if color:
        # colorカラムの値でデータをグループ分け
        grouped = df.groupby(color)

        # 各グループのxカラムのデータをリストに格納、可視化用に逆順に並び替え
        hist_data = [group[x].values for _, group in grouped][::-1]

        # 各グループの名前(colorカラムの値)をラベルとしてリストに格納、可視化用に逆順に並び替え
        labels = [str(name) for name, _ in grouped][::-1]

        # 密度プロットとヒストグラムを作成
        fig = ff.create_distplot(
            hist_data, labels, show_hist=show_hist, show_rug=show_rug, **kwargs
        )
    else:
        # colorが指定されていない場合はx列の全データを用いる
        hist_data = [df[x].values]

        # 密度プロットを作成(ラベルはxを指定)
        fig = ff.create_distplot(
            hist_data,
            group_labels=[x],
            show_hist=show_hist,
            show_rug=show_rug,
            **kwargs
        )

    # x軸のタイトルをxに変更
    fig.update_xaxes(title=x)

    # y軸のタイトルを"確率密度"に変更
    fig.update_yaxes(title="確率密度")

    # 作成されたプロットを返す
    return fig
Hide code cell content
def format_cols(df: pd.DataFrame, cols_rename: Dict[str, str]) -> pd.DataFrame:
    """
    指定されたカラムのみをデータフレームから抽出し、カラム名をリネームする関数

    Parameters
    ----------
    df : pd.DataFrame
        入力データフレーム
    cols_rename : Dict[str, str]
        リネームしたいカラム名のマッピング(元のカラム名: 新しいカラム名)

    Returns
    -------
    pd.DataFrame
        カラムが抽出・リネームされたデータフレーム
    """

    # 指定されたカラムのみを抽出し、リネーム
    df = df[cols_rename.keys()].rename(columns=cols_rename)

    return df
Hide code cell content
def save_df_to_csv(df: pd.DataFrame, dir_save: Path, fn_save: str) -> None:
    """
    DataFrameをCSVファイルとして指定されたディレクトリに保存する関数

    Parameters
    ----------
    df : pd.DataFrame
        保存対象となるDataFrame
    dir_save : Path
        出力先ディレクトリのパス
    fn_save : str
        保存するCSVファイルの名前(拡張子は含めない)
    """
    # 出力先ディレクトリが存在しない場合は作成
    dir_save.mkdir(parents=True, exist_ok=True)

    # 出力先のパスを作成
    p_save = dir_save / f"{fn_save}.csv"

    # DataFrameをCSVファイルとして保存する
    df.to_csv(p_save, index=False, encoding="utf-8-sig")

    # 保存完了のメッセージを表示する
    print(f"DataFrame is saved as '{p_save}'.")

可視化例#

マンガデータ#

マンガ作品の各話数の分布を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ce = pd.read_csv(DIR_CM / FN_CE)
Hide code cell content
# マンガ雑誌の掲載データから、特定の条件を満たす作品のみを選択して集計を行う

# 各マンガ作品(ccid)に対して、掲載された回数(ceidのユニーク数)をカウント
df_tmp = df_ce.groupby("ccid")["ceid"].nunique().reset_index(name="n_ce")

# 掲載された回数がMIN_N_CE以上のマンガ作品のIDをリストとして取得
ccids = df_tmp[df_tmp["n_ce"] >= MIN_N_CE]["ccid"].unique().tolist()

# 上で取得したマンガ作品IDのみを含むデータをdf_cmに格納
df_cm = df_ce[df_ce["ccid"].isin(ccids)].reset_index(drop=True)

# 必要なカラムのみを選択し、カラム名をわかりやすいものに変更
cols_cm = {
    "mcname": "マンガ雑誌名",
    "pages": "一話あたりのページ数",
    "date": "掲載日",
    "ccname": "マンガ作品名",
    "ceid": "各話ID",
}
df_cm = format_cols(df_cm, cols_cm)

ヒストグラムでも確認した通り、このデータ中にはpagesが非常に大きいものが存在します。 仮に50ページ以上のマンガ各話は異常値であるとして、それ以外のデータに絞って可視化してみましょう。

Hide code cell content
# ページ数が50ページ以内のデータのみを選択する

# 抽出する最大のページ数を50ページに設定
max_pages = 50

# ページ数が50ページ以内のデータのみを選択し、df_cmを更新
df_cm = df_cm[df_cm["一話あたりのページ数"] <= max_pages].reset_index(drop=True)
Hide code cell content
# 可視化対象のDataFrameを確認
df_cm.head()
マンガ雑誌名 一話あたりのページ数 掲載日 マンガ作品名 各話ID
0 週刊少年マガジン 22.0 2011-05-25 ダイヤのA CE00000
1 週刊少年マガジン 18.0 2011-05-25 君のいる町 CE00001
2 週刊少年マガジン 18.0 2011-05-25 アゲイン!! CE00002
3 週刊少年マガジン 20.0 2011-05-25 FAIRY TAIL CE00003
4 週刊少年マガジン 20.0 2011-05-25 A-BOUT! CE00004
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_cm, DIR_OUT_CM, "cm")
DataFrame is saved as '../../data/cm/output/06/density/cm.csv'.
Hide code cell source
# ページ数をx軸として密度プロットを作成
fig = create_distplot(df_cm, x="一話あたりのページ数")

# 密度プロットを表示
show_fig(fig)

上図は、50ページ以下のマンガ各話について、ページ数の分布を密度プロットで表現したものです。

ヒストグラムによる表現と比較し、分布がなめらかになっていることがわかります。 ページ数は離散変数であるためその恩恵が薄いですが、特に連続変数の密度をプロットする際、特に有効です。

ただし、密度プロットではX軸について積分した結果が1となるようにY軸のスケールを調整しているため、上記のようにY軸の数値が非常に小さくなることがあります。 同様の理由から、度数(この場合は該当する各話数)に相当する情報を得ることはできません。

密度プロットでは、複数のデータを同時に描画することが可能です。

Hide code cell source
# create_distplot関数を用いて、df_cmの"一話あたりのージ数"に基づく密度プロットを作成
# 色分けは"マンガ雑誌名"カラムに基づき、色のパレットとしてOKABE_ITOを使用
fig = create_distplot(
    df_cm, x="一話あたりのページ数", color="マンガ雑誌名", colors=OKABE_ITO
)

# show_fig関数を用いて、作成したプロットを表示
show_fig(fig)

(少し見づらいですが)各マンガ雑誌のページ数の分布の違いが明らかになりました。 特に、週刊少年サンデー週刊少年ジャンプ週刊少年チャンピオンのページ数のピークに明確な違いがあります。

しかし、ヒストグラムで指摘したようなマンガ雑誌ごとの特徴は分かりづらくなりました。 例えば、週刊少年ジャンプのページ数のピークが19ページ付近にあることはわかりますが、奇数ページ数を基本にしていることは一見してわかりません。

また、密度プロットはデータの分布をカーネル密度関数で 近似した表現 に過ぎないという事実は覚えておく必要があります。 この近似の結果、実際には存在しない数値にもデータが分布しているような印象を与えることがあります。 例えば、今回の描画対象はマンガ各話のページ数ですので、整数値しか存在しません。 しかし上図を見る限り、 小数のページ数も存在 するように見えます。 今回はマンガのページ数ですので常識に基づいて判断が可能ですが、高度な専門知識が必要な変数に関しては補足説明が必要です。 特に離散変数に対して密度プロットを採用する際は注意しておきましょう。

分布形状が近い場合は、以下のようにサブプロットにわける方が賢明です。

Hide code cell source
# データフレームからユニークなマンガ雑誌名を取得
mcnames = df_cm["マンガ雑誌名"].unique()
# サブプロットを配置するための列数を設定
cols = 2
# サブプロットを配置するための行数を計算(切り上げ除算で行数を確定)
rows = -(-len(mcnames) // cols)

# 複数のサブプロットを持つ図を作成。各マンガ雑誌名をサブプロットのタイトルとして設定
fig = make_subplots(rows=rows, cols=cols, subplot_titles=mcnames)
# y軸の最大値を格納するためのリストを初期化
y_max_values = []

# マンガ雑誌名の数だけ繰り返し処理
for i, mcname in enumerate(mcnames):
    # 現在のマンガ雑誌名に対応するデータをフィルタリング
    df_mc = df_cm[df_cm["マンガ雑誌名"] == mcname]
    # ページ数の密度プロットを作成
    distplot = create_distplot(df_mc, "一話あたりのページ数")

    # 現在のサブプロットの位置を行(row)と列(col)で計算
    row = i // cols + 1
    col = i % cols + 1

    # 各サブプロットのy軸の最大値をリストに追加
    y_max_values.append(np.max([trace.y for trace in distplot.data]))

    # 作成した密度プロットを図に追加
    for trace in distplot.data:
        trace.showlegend = False  # 凡例を非表示に設定
        fig.add_trace(trace, row=row, col=col)

# 全サブプロットの中で最大のy軸値を計算
y_max = np.max(y_max_values)

# 全サブプロットのy軸の範囲を統一して設定
# 最大値の1.1倍で少し余裕を持たせる
for i in range(1, rows * cols + 1):
    fig.update_yaxes(
        range=[0, y_max * 1.1], row=(i - 1) // cols + 1, col=(i - 1) % cols + 1
    )

# 作成した図を表示する
show_fig(fig)

大まかにではありますが、マンガ雑誌によって分布形状が異なることがわかりやすくなりました。

なお、上図のようにサブプロットを用いる場合は、基本的に全てのサブプロットのX軸とY軸の範囲が等しくなるように注意しましょう。

受け手は、暗黙のうちに軸が共通しているという前提を持ちます。 表示範囲が異なると、誤った印象を与えてしまう可能性があります。 特に密度プロットでは、Y軸の値が分布形状に応じて大きく異なることがあります。

アニメデータ#

アニメ作品の各話数の分布を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ae = pd.read_csv(DIR_AN / FN_AE)
Hide code cell content
# アニメ作品ごとに話数を集計する

# 'acid'と'acname'(アニメ作品IDとアニメ作品名)ごとにユニークな'aeid'(各話のID)の数をカウントする
# これにより、各アニメ作品の合計話数が計算される
df_an = df_ae.groupby(["acid", "acname"])["aeid"].nunique().reset_index(name="n_ae")

# 列名をわかりやすいものに変更
cols_an = {"acid": "アニメ作品ID", "acname": "アニメ作品名", "n_ae": "アニメ作品の合計話数"}
df_an = format_cols(df_an, cols_an)
Hide code cell content
# 可視化対象のDataFrameを可視化
df_an.head()
アニメ作品ID アニメ作品名 アニメ作品の合計話数
0 C10001 ギャラクシー エンジェル 24
1 C10003 PROJECT ARMS 26
2 C10005 探偵少年カゲマン 6
3 C10006 Mr.Digital TOKORO the comical cartoon [第1期] 120
4 C10008 GEAR戦士[ギアファイター] 電童 38
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_an, DIR_OUT_AN, "an")
DataFrame is saved as '../../data/an/output/06/density/an.csv'.
Hide code cell source
# アニメ作品の合計話数の密度プロットを作成する
fig = create_distplot(df_an, x="アニメ作品の合計話数")

# 作成した密度プロットを表示する
show_fig(fig)

上図は、アニメ作品の合計話数の分布を表した密度プロットです。

非常に合計話数が多い作品があるため、ヒストグラムと同様にX軸の表示範囲が広がってしまいました。 ヒストグラムとの違いは、実際のデータ点と近似値の区別がつきづらいことです。 このような場合は、show_rugオプションを利用すると良いでしょう。

Hide code cell source
# アニメ作品の合計話数の密度プロットを作成する
fig = create_distplot(df_an, x="アニメ作品の合計話数", show_rug=True)

# 作成した密度プロットを表示する
show_fig(fig)

図中下部を確認することで、密度プロットの基になったデータ点を確認することができます。 ただし、show_rugオプションを利用するとプロット自体のデータサイズが大きくなってしまうことに注意してください。

ヒストグラムと同様に、X軸の表示範囲を変更してみましょう。

Hide code cell source
# アニメ作品の合計話数の密度プロットを作成する
fig = create_distplot(df_an, x="アニメ作品の合計話数")

# X軸の表示範囲を0話からMAX_N_AE話までに更新する
fig.update_xaxes(range=[0, MAX_N_AE])

# 更新した密度プロットを表示する
show_fig(fig)

分布に対する近似が緩すぎて、ヒストグラムで表現されていたクールごとのピーク値の情報が削げ落ちています。

このような場合、一般的には密度プロットのバンド幅(bandwidth)を調整すると良いでしょう。 バンド幅はヒストグラムにおけるビン幅のようなもので、分布を近似する粒度を調整するためのパタメータです。

残念ながら、本書執筆時点のPlotly v5.18.0の公式APIドキュメント[2]によると、plotly.figure_factory.create_distplotの引数にbandwidthに相当するものはないようです。 正攻法ではありませんが、表示範囲に合わせて事前にデータをフィルタリングすることでこの問題を解決できます。

Plotly v5.18.0における密度プロットの実装

本書執筆時点の最新版であるv5.18.0のmake_kde()の実装を見る限り、scipy.stats.gaussian_kdeを使って密度プロットを描画しているようです。 本来scipy.stats.gaussian_kdeには引数としてbandwidthを渡すことはできますが、Plotlyの内部ではデータを渡すだけで、bandwidthを指定できる実装になっていないように見えます。 なお、scipy.stats.gaussian_kdeにおいてbandwidthを特に指定しない場合は、scott法[Scott, 1992]で自動決定されるようです。

Hide code cell source
# 事前にMAX_N_AE以下の合計話数のレコードのみを抽出
df_an_filtered = df_an[df_an["アニメ作品の合計話数"] <= MAX_N_AE].reset_index(drop=True)

# アニメ作品の合計話数の密度プロットを作成する
fig = create_distplot(df_an_filtered, x="アニメ作品の合計話数")

# 更新した密度プロットを表示する
show_fig(fig)

上図は、事前にデータをフィルタリングした上で作図した密度プロットです。 近似の粒度が適切に調整され、複数のピークが存在することがわかりやすくなりました。

では次は、放送曜日ごとの密度プロットを作成してみましょう。

Hide code cell content
# dateカラムから曜日を取得し、新しいカラム"weekday"に数値として格納
# pandasのweekday関数では、0が月曜日、6が日曜日となる
df_ae["weekday"] = pd.to_datetime(df_ae["date"]).dt.weekday

# アニメ作品名、曜日ごとに、ユニークな話数をカウント
df_an2 = (
    df_ae.groupby(["acid", "acname", "weekday"])["aeid"]
    .nunique()
    .reset_index(name="n_ae")
)

# MAX_N_AE以下でフィルタリング
df_an2 = df_an2[df_an2["n_ae"] <= MAX_N_AE].reset_index(drop=True)

# 曜日を数値の昇順(月曜日から日曜日)にソート
df_an2 = df_an2.sort_values("weekday", ignore_index=True)

# 数値として格納されている曜日を実際の曜日名(月、火、...)に変換
df_an2["weekday"] = df_an2["weekday"].map(WEEKDAY2YOBI)

# カラム名をわかりやすいものに変更
cols_an2 = {
    "acid": "アニメ作品ID",
    "acname": "アニメ作品名",
    "weekday": "放送曜日",
    "n_ae": "アニメ作品の合計話数",
}
df_an2 = format_cols(df_an2, cols_an2)
Hide code cell content
# 可視化対象のDataFrameを確認
df_an2.head()
アニメ作品ID アニメ作品名 放送曜日 アニメ作品の合計話数
0 C9082 忍たま乱太郎 第2期 24
1 C13639 俺たちに翼はない --under the innocent sky. 12
2 C10978 がくえんゆーとぴあ まなびストレート! 12
3 C16032 ポンコツクエスト -魔王と派遣の魔物たち- [第3期] 12
4 C9348 花より男子 1
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_an2, DIR_OUT_AN, "an2")
DataFrame is saved as '../../data/an/output/06/density/an2.csv'.
Hide code cell source
# create_distplot関数を用いて、df_an2の"アニメ作品の合計話数"に基づく密度プロットを作成
# 色分けは"放送曜日"カラムに基づき、色のパレットとしてOKABE_ITOを使用
fig = create_distplot(df_an2, x="アニメ作品の合計話数", color="放送曜日", colors=OKABE_ITO)

# show_fig関数を用いて、作成したプロットを表示
show_fig(fig)

上図は、曜日ごとのアニメ合計話数の分布を表す密度プロットです。

ヒストグラムと異なり、(もちろん限度はありますが)複数の凡例を重ねても互いを識別可能です。 特に、金曜に放送された1クール(13話)のアニメ作品の数が非常に多いことがわかります。 また、4クール(52話)のアニメに関しては、土日に放送されることが多いという点もわかりやすくなりました。

また、密度プロットで表現されているのは 絶対量ではない という点は、必ず受け手に伝えるようにしましょう。

前提知識のない方が上の図を見ると、各曜日で放送されているアニメ作品数はほとんど同じである(なぜならプロットが重なっている部分が多いから)という印象を持つ可能性があります。 しかし、実際には 分布の形状が似ているだけで、絶対量が同等である保証はありません

ゲームデータ#

ゲームパッケージの価格の分布を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_pkg_pf = pd.read_csv(DIR_GM / FN_PKG_PF)
Hide code cell content
# df_pkg_pfから必要なカラムのみを選択し、df_gmとして新しくデータフレームを作成
df_gm = df_pkg_pf[["pfname", "pkgname", "price"]]

# priceがNaNの行を削除。ignore_indexオプジョンで既存のインデックスを無視して再設定
df_gm = df_gm.dropna(subset=["price"], ignore_index=True)

# df_gmのカラム名をより分かりやすい名称に変更
cols_gm = {
        "pfname": "プラットフォーム名",
        "pkgname": "パッケージ名",
        "price": "ゲームパッケージの価格",
    }
df_gm = format_cols(df_gm, cols_gm)
Hide code cell content
# 可視化対象のDataFrameを確認
df_gm.head()
プラットフォーム名 パッケージ名 ゲームパッケージの価格
0 ゲームボーイアドバンス くにおくん 熱血コレクション 1 5040.0
1 セガサターン 野々村病院の人々 6800.0
2 セガサターン アイドル雀士スーチーパイ Remix 6900.0
3 セガサターン 天地無用! 魎皇鬼 ごくらくCD-ROM for SEGA SATURN 7800.0
4 3DO Superリアル麻雀 P4 + 相性診断 9500.0
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_gm, DIR_OUT_GM, "gm")
DataFrame is saved as '../../data/gm/output/06/density/gm.csv'.
Hide code cell source
# df_gmの`価格`に基づいて密度プロットを表示
fig = create_distplot(df_gm, x="ゲームパッケージの価格")

# ヒストグラムを実際に表示するための関数を実行
show_fig(fig)

上図は、ゲームパッケージの価格の分布を表現した密度プロットです。

少し見づらいので、表示領域を拡大してみましょう。 アニメデータと同様に、元データをフィルタリングすることで密度プロットの粒度を調整します。

Hide code cell source
# df_gmのうち、`ゲームパッケージの価格`がMAX_PRICE以下のレコードを抽出
df_gm_filtered = df_gm[df_gm["ゲームパッケージの価格"] <= MAX_PRICE].reset_index(drop=True)

# df_gm_filteredの`ゲームパッケージの価格`に基づいて密度プロットを表示
fig = create_distplot(df_gm_filtered, x="ゲームパッケージの価格")

# 更新した設定でヒストグラムを実際に表示するための関数を実行
show_fig(fig)

ヒストグラムと見比べてみましょう。 密度プロットでは、1000円ごとにピークがあるという事実が分かりづらくなっています。

ゲームプラットフォームごとの価格の分布を表示してみましょう。

Hide code cell content
# 各ゲームプラットフォームごとにパッケージ数を集計
df_tmp = df_pkg_pf.groupby("pfname")["pkgid"].nunique().reset_index(name="n_pkg")

# パッケージ数が多い上位5のゲームプラットフォーム名をリストとして取得
# 上位5のゲームプラットフォームに絞ったデータのみを抽出
pfnames = df_tmp.sort_values("n_pkg", ascending=False)["pfname"].head(5).tolist()
df_gm2 = df_gm[df_gm["プラットフォーム名"].isin(pfnames)].reset_index(drop=True)

# df_gm2のうち、`ゲームパッケージの価格`がMAX_PRICE以下のレコードを抽出
df_gm2 = df_gm2[df_gm2["ゲームパッケージの価格"] <= MAX_PRICE].reset_index(drop=True)

# プラットフォーム名のリストの順番になるようにカテゴリカル型に変換
df_gm2["プラットフォーム名"] = pd.Categorical(
    df_gm2["プラットフォーム名"], categories=pfnames, ordered=True
)
# `プラットフォーム名`カラムをもとにデータをソート
df_gm2 = df_gm2.sort_values("プラットフォーム名", ignore_index=True)
Hide code cell content
# 可視化対象のDataFrameを確認
df_gm2.head()
プラットフォーム名 パッケージ名 ゲームパッケージの価格
0 プレイステーション2 THE MATRIX: PATH of NEO 6700.0
1 プレイステーション2 デフジャム・ファイト・フォー・NY EA BEST HITS 3129.0
2 プレイステーション2 てのひらを、たいように ~永久の絆~ 7140.0
3 プレイステーション2 デッドトゥ ライツ 7140.0
4 プレイステーション2 デストロイ オール ヒューマンズ! THQ Collection 3129.0
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_gm2, DIR_OUT_GM, "gm2")
DataFrame is saved as '../../data/gm/output/06/density/gm2.csv'.
Hide code cell source
# 'ゲームパッケージの価格'を基に密度プロットを作成し、'プラットフォーム名'によって色分け
# colors=OKABE_ITOで指定された色のリストを使用
fig = create_distplot(
    df_gm2, x="ゲームパッケージの価格", color="プラットフォーム名", colors=OKABE_ITO
)

# show_fig関数を用いて、作成したプロットを表示
show_fig(fig)

上図は、ゲームパッケージの価格の分布を、ゲームプラットフォームごとに表現した密度プロットです。 それぞれのゲームパッケージの価格分布の違いがわかりやすくなりました。

繰り返しになりますが、密度プロットで表現されているのは、 絶対量ではない という点に注意が必要です。 例えば、上の図はプレイステーションプレイステーション2とその他のゲームプラットフォームでゲームパッケージ がほとんどかわらないという誤った印象を与えてしまいがちですが、実際には全く異なります。 凡例(ゲームプラットフォーム)をまたいで比較する際は、絶対量ではなく分布形状のみに着目するよう、受け手を誘導する必要があります。

この課題を解決する最もシンプルな方法は、グラフを分けることです。

Hide code cell source
# サブプロットを配置するための列数を設定
cols = 2
# サブプロットを配置するための行数を計算(切り上げ除算で行数を確定)
rows = -(-len(pfnames) // cols)

# 複数のサブプロットを持つ図を作成。各マンガ雑誌名をサブプロットのタイトルとして設定
fig = make_subplots(rows=rows, cols=cols, subplot_titles=pfnames)
# y軸の最大値を格納するためのリストを初期化
y_max_values = []

# ゲームプラットフォームの数だけ繰り返し処理
for i, pfname in enumerate(pfnames):
    # 現在のプラットフォーム名に対応するデータをフィルタリング
    df_pf = df_gm2[df_gm2["プラットフォーム名"] == pfname]
    # 価格の密度プロットを作成
    distplot = create_distplot(df_pf, "ゲームパッケージの価格")

    # 現在のサブプロットの位置を行(row)と列(col)で計算
    row = i // cols + 1
    col = i % cols + 1

    # 各サブプロットのy軸の最大値をリストに追加
    y_max_values.append(np.max([trace.y for trace in distplot.data]))

    # 作成した密度プロットを図に追加
    for trace in distplot.data:
        trace.showlegend = False  # 凡例を非表示に設定
        fig.add_trace(trace, row=row, col=col)

# 全サブプロットの中で最大のy軸値を計算
y_max = np.max(y_max_values)

# 全サブプロットのy軸の範囲を統一して設定
# 最大値の1.1倍で少し余裕を持たせる
for i in range(1, rows * cols + 1):
    fig.update_yaxes(
        range=[0, y_max * 1.1], row=(i - 1) // cols + 1, col=(i - 1) % cols + 1
    )

# 作成した図を表示する
show_fig(fig)

上図は、ゲームパッケージの価格の分布を、ゲームプラットフォームごとにファセットに分けて表現した密度プロットです。 合計パッケージ数が多い上位五つのゲームプラットフォームを可視化対象としています。

ファセットに分けることで重複が解消され、グラフが見やすくなったとともに、前述したパッケージ数に関する誤解を避けられる効果が期待できます。 一方で、一つ一つのグラフは小さくなってしまったため、細かい部分を確認するには不向きです。 また、各プラットフォームの確率密度分布の 形状 を比較する際、特に隣接していないグラフ同士を見比べることが困難になってしまいました。